有匿名者指定說他也會逛 PTT,最但最常逛表特版,所以詢問看看是否能夠自幹表特版 api。我想這種需求看起來挺強烈的,這麼造福廣大男性朋友的 api 肯定是需要的,那我們這次的目標就來抓取 ptt 表特版最新的圖片並將他製作成 api 吧!
話說我平常真的不太逛表特版,所以對裡面的東西並不是太熟悉...但就觀察的結果,每篇文章基本上都會貼上圖片的網址,大概 99% 使用的 imgur 這個圖床,所以這次主題我們就專注在 imgur 的圖片上吧。
進入表特版網址 https://www.ptt.cc/bbs/Beauty/index.html,我們可以看到分頁,然後每個分頁最多是 20 篇文章。觀察一下上頁的連結是 /bbs/Beauty/index2339.html
,猜測目前這一頁應該是 /bbs/Beauty/index2340.html
,點擊上頁可以看到更多文章,同時也確認剛剛的首頁確實是 /bbs/Beauty/index2340.html
,那也就是說我可以抓到 2340 個分頁。
若點擊文章網址,就會進入文章頁面,裡面大部分是一堆 imgur 網址的圖片,若我們能把每一篇文章的圖片位置都抓下來,那麼就能製作出我們的 api 了。
不過在進入研究之前,我們必須先考慮一個問題,若我們真的抓了 2340 個分頁,這麼一來肯定會抓很久,二來絕對會造成伺服器的負擔,這並不是我們的本意,我們的目標只是抓取最新的圖片,但最新如何定義呢?那我們姑且就先定義為最後三個分頁。
確認我們的目標是抓前三個分頁以後的所有文章內 imgur 的連結後,我們可以把步驟分成三個動作:
從 dom 的結構看上頁大概就能推算我們要抓取這三個分頁的 url,接著我們來試著 select 頁面上的文章列表。
接著來 request 一下看看是否能夠確定抓取分頁,然後再 request 測試一下文章頁面,看起來單純的 get request 應該就沒問題了。
最後我們來測試使用 regex 來抓取 imgur 的連結,在這個部分有注意到 imgur 的連結格式有兩種,但可以歸納只需抓取類似 imgur.com/APpvLiF 這種格式就可以了,後續手動補上完整網址就好。
首先我們先來抓前三個分頁,基本上就是 index.html,然後去看上頁的 url,然後上上頁就是將這個 url 的數字部分再減一,也就是說,我們只需要抓上頁的 url 就能清楚整個數列,最後再把這三個頁面的數字部分傳給 callback。
function getTopPages(callback) {
request('https://www.ptt.cc/bbs/Beauty/index.html', (err, res, body) => {
var $ = cheerio.load(body)
var prev = $('.btn-group-paging a').eq(1).attr('href').match(/\d+/)[0]
callback(['', prev, prev - 1])
})
}
接著我們來抓取每個分頁的文章列表,基本上沒有什麼難度,單純 select,然後把我們抓到的 posts 丟給 callback。
function getPosts(page, callback) {
request(`https://www.ptt.cc/bbs/Beauty/index${page}.html`, (err, res, body) => {
var $ = cheerio.load(body)
var posts = $('.r-ent a').map((index, obj) => {
return $(obj).attr('href')
}).get()
callback(posts)
})
}
最後我們來抓取每篇文章內的圖片連結,因為觀察到所有的 imgur 圖片的 ID 都是 7個字串,那麼我們使用 regex 來 match /imgur.com\/[0-9a-zA-Z]{7}/g
就行了。不過因為 ptt 網頁會自動加載圖片,所以抓取時會抓到重複的 image url,所以我們必須做 array uniq,可以使用 es6 的語法 [ ...new Set(images) ]
就能簡潔的處理掉重複的 array content。
function getImages(post, callback) {
request('https://www.ptt.cc' + post, (err, res, body) => {
var images = body.match(/imgur.com\/[0-9a-zA-Z]{7}/g);
images = [ ...new Set(images) ]
callback(images);
})
}
我們先 call getTopPages function 抓取三個分頁,然後再使用 async.map 來跑 getPosts function,去抓取這三個分頁的所有文章列表。抓完文章列表後,我們再使用 async.map 來跑 getImages function,去抓取每篇文章內的 images url,最後再將 images url 補完前綴和後綴,這樣就能抓到最新的所有圖片列表了。
getTopPages((pages) => {
async.map(pages, (page, callback) => {
getPosts(page, (posts) => {
callback(null, posts)
})
}, (err, results) => {
var posts = [].concat.apply([], results)
async.map(posts, (post, callback) => {
getImages(post, (images) => {
callback(null, images)
})
}, (err, results) => {
var images = [].concat.apply([], results).map((image)=>{
return 'https://' + image + '.jpg'
})
console.log(images)
})
})
})
接下來我們用 express 來做 web service,先暫時聽 3000 port,未來 deploy 再改 80,經過測試沒問題就大功告成了。
const express = require('express');
const app = express();
app.get('/', function (req, res) {
getTopPages((pages) => {
async.map(pages, (page, callback) => {
getPosts(page, (posts) => {
callback(null, posts)
})
}, (err, results) => {
var posts = [].concat.apply([], results)
async.map(posts, (post, callback) => {
getImages(post, (images) => {
callback(null, images)
})
}, (err, results) => {
var images = [].concat.apply([], results).map((image)=>{
return 'https://' + image + '.jpg'
})
res.json(images)
})
})
})
}).listen(3000)
const request = require('request');
const cheerio = require('cheerio');
const async = require('async');
const express = require('express');
const app = express();
app.get('/', function (req, res) {
getTopPages((pages) => {
async.map(pages, (page, callback) => {
getPosts(page, (posts) => {
callback(null, posts)
})
}, (err, results) => {
var posts = [].concat.apply([], results)
async.map(posts, (post, callback) => {
getImages(post, (images) => {
callback(null, images)
})
}, (err, results) => {
var images = [].concat.apply([], results).map((image)=>{
return 'https://' + image + '.jpg'
})
res.json(images)
})
})
})
}).listen(3000)
function getTopPages(callback) {
request('https://www.ptt.cc/bbs/Beauty/index.html', (err, res, body) => {
var $ = cheerio.load(body)
var prev = $('.btn-group-paging a').eq(1).attr('href').match(/\d+/)[0]
callback(['', prev, prev - 1])
})
}
function getPosts(page, callback) {
request(`https://www.ptt.cc/bbs/Beauty/index${page}.html`, (err, res, body) => {
var $ = cheerio.load(body)
var posts = $('.r-ent a').map((index, obj) => {
return $(obj).attr('href')
}).get()
callback(posts)
})
}
function getImages(post, callback) {
request('https://www.ptt.cc' + post, (err, res, body) => {
var images = body.match(/imgur.com\/[0-9a-zA-Z]{7}/g);
images = [ ...new Set(images) ]
callback(images);
})
}
在這個主題,我們其實有兩個假設性的設定,第一個是我們定義了最新圖片為前三個分頁,若更近一步的開發,其實是可以在 api 的參數收 page number,交由使用者來決定他要抓幾個分頁,但這個就必須考量到爬蟲的速度,和 ptt web server 的防堵機制,總不可能放任使用者去抓所有的分頁,不然可是有很大的機會會被直接鎖 IP 的。
另外一個是我們只抓 imgur 的圖片連結,但現實上是有可能出現其他種類的圖片網址的,關於這點,其實我們無法把所有狀況含括,可以有幾種想法,例如我們去判斷副檔名,但這也不見得是百分之百,或者我們去讀取 ptt web server 所附帶的圖片解析器,這或許比較靠譜,不過這就另外的課題了。
這篇讓我聯想起多年前的 diggirl 和幹圖王
順手 Google 了一下作者獨孤木
專案管理界有名的作者
才突然發現他今年八月份過世了
一代技術強者
令人感慨萬千呀
R.I.P.
挖出一篇獨孤木參與的古文來回味一下
有沒有不會寫程式卻在做系統分析的?
原本是個沒事拿來閒嗑牙的問題
後來卻引發跨越幾年、幾百篇的大戰
孤獨木
R.I.P.
小弟在 10 年前還是個萌懂的小鬼
所以沒有參與到這百篇大戰
但這個主題縱使到了今日,我想還是可以有百篇的論戰點
要不要我們也來開一篇大家回味一下
我試著把這百篇討論串看完
真的是好文!!
請問前輩
可以示範如何大量將imgur的圖片,下載下來嗎?
小弟照著網路上的教學做,貌似是流量太大
就會被imgur Ban掉,不知道前輩能否提點下呢.
謝謝.
基本上被 ban 那是肯定會的
而方法其實比較被動
要嘛就是拉長抓取的間隔時間
要嘛就換 IP 抓